查看原文
其他

原创 | 驱动病毒那些事(二)—— 回调

李哈哈 SecIN技术平台 2022-06-18

点击上方蓝字 关注我吧



前言


在分析驱动病毒的时候,病毒为了实现其恶意功能不可避免的会注册各种回调函数。内核模块并不是生成一个进程,只是填写一组回调函数让Windows来调用。我们只有深入了解了回调函数的整个调用流程后,分析病毒起来才能更加得心应手。
文章有些长,请大家耐心阅读。


进程回调
注册进程回调的函数为函数为PsSetCreateProcessNotifyRoutine,看一下该函数的定义


该函数需要两个参数,第一个参数NotifyRoutine就是你要创建的回调历程,第二个参数是一个布尔类型,如果为TURE,代表移除进程回调,如果为FALSE,代表将回调历程加入列表内。
在驱动病毒中,注册进程回调的行为非常常见,比如病毒注册进程回调,当打开浏览器时,修改ProcessParameters中的cmdline参数实现劫持主页的目的。
如果想要摘除其注册的进程回调,仅需找到NotifyRoutine地址,然后构造
PsSetCreateProcessNotifyRoutine(callbackaddr,TRUE)即可摘除进程回调。当然病毒也可以通过此方式摘除杀软的进程回调来保护自身模块。

下面我们来看一下PsSetCreateProcessNotifyRoutine具体的执行流程,我将结合WRK源码和windbg反汇编的结果来为大家展示函数执行全过程(以win7 x64为例,xp执行流程稍有不同)。



我们用dq命令,查看PspCreateProcessNotifyRoutine内容,惊奇的发现了8个地址,此时打开Pchunter,在主机上存在8个进程回调,此时我天真的以为PspCreateProcessNotifyRoutine中存的地址就是进程回调函数的地址,但是和Pchunter中的地址进行,发现并不一样,苦恼。


但是我坚信这两种之前一定存在着某种关联。




源代码分析


此路不通,我试图从WRK源码中寻找蛛丝马迹,重点部分我已标出。


PsSetCreateProcessNotifyRoutine函数首先调用了ExReferenceCallBackBlock函数,而传入该函数的参数正是我们之前看到的PspCreateProcessNotifyRoutine, PspCreateProcessNotifyRoutine指向存在回调结构的数组。


而且我们观察到该函数的返回值类型为EX_CALLBACK_ROUTINE_BLOCK,一个结构体。


查看该结构体细节:

typedef struct _EX_CALLBACK_ROUTINE_BLOCK { EX_RUNDOWN_REF RundownProtect; PEX_CALLBACK_FUNCTION Function; PVOID Context;} EX_CALLBACK_ROUTINE_BLOCK, *PEX_CALLBACK_ROUTINE_BLOCK;


结构体中Function字段存储的正是我们注册的回调地址。

我们进入ExReferenceCallBackBlock函数观察细节。

先看一下函数传入的参数和返回值,再结合函数描述,这个函数的作用应该是取得回调结构的调用。


PspCreateProcessNotifyRoutine数组中保存的是 PEX_CALLBACK 类型的结构地址数组。


看一下EX_CALLBACK结构体,结构中只有一个成员,就是 RoutineBlock ,它是一个 EX_FAST_REF 结构。

typedef struct _EX_CALLBACK { EX_FAST_REF RoutineBlock;} EX_CALLBACK, *PEX_CALLBACK;


继续往下看。


首先调用了ExFastReference函数,该函数的作用是获取_EX_FAST_REF结构体。



查看结构体成员,该结构体有两个成员,引用计数变量和Value指针。重要的是value指针,它指向的正是之前提到过的EX_CALLBACK_ROUTINE_BLOCK结构。

typedef struct _EX_FAST_REF { union { PVOID Object;#if defined (_WIN64) ULONG_PTR RefCnt : 4;#else ULONG_PTR RefCnt : 3;#endif ULONG_PTR Value; };} EX_FAST_REF, *PEX_FAST_REF;


最重要的部分来了,激动.ing!!! 
ExFastRefGetObject 将传进来的EX_FAST_REF指针进行了与运算就得到 PEX_CALLBACK_ROUTINE_BLOCK 结构地址。


MAX_FAST_REFS是一个常量,根据64位系统和32位系统决定其值。



现在我们基本理清了操作系统获取回调函数地址的整个流程,下面进行验证。之前我们已经获取了PspCreateProcessNotifyRoutine地址。我们拿ffff8a00103a91f进行举例。


用ffff8a00103a91f&~15即可得到EX_CALLBACK_ROUTINE_BLOCK结构体地址。


还记得该结构体内容吗?
typedef struct _EX_CALLBACK_ROUTINE_BLOCK { EX_RUNDOWN_REF RundownProtect; PEX_CALLBACK_FUNCTION Function; PVOID Context;} EX_CALLBACK_ROUTINE_BLOCK, *PEX_CALLBACK_ROUTINE_BLOCK;


该结构体第二个成员就是我们梦寐以求的回调函数地址。


用PCHunter进行验证,证明我们的结论。



回调摘除
我们来梳理一下思路。PsSetCreateProcessNotifyRoutine函数调用PspSetCreateProcessNotifyRoutine函数,在PspSetCreateProcessNotifyRoutine函数中可以获取PspCreateProcessNotifyRoutine,在PspCreateProcessNotifyRoutine中 保存的是指针数组。
系统将数组中的指针传递给 ExFastRefGetObject,就得到了PEX_CALLBACK_ROUTINE_BLOCK 结构地址,这个结构中保存着我们想要的回调例程地址;
获取到callback地址后,如果传进来的参数为FALSE,即注册一个进程回调。首先会调用ExAllocateCallBack申请一个块内存,内容为null。
接着调用ExCompareExchangeCallBack,在数组PspCreateProcessNotifyRoutine找一个空位存放callback,最后将全局计数变量PspCreateProcessNotifyRoutineCount加1。
同理,我们如果想要摘除进程回调,只需找到callback地址,调用PsSetCreateProcessNotifyRoutine(callback,TRUE)即可。
在调用PsSetCreateProcessNotifyRoutine函数注册进程回调时,会传入两个参数
PCREATE_PROCESS_NOTIFY_ROUTINE NotifyRoutine和 BOOLEAN Remove。

而NotifyRoutine 的参数类型为VOID (*PCREATE_PROCESS_NOTIFY_ROUTINE)( IN HANDLE ParentId,IN HANDLE ProcessId,IN BOOLEAN Create);
也就是说当有新的进程被创建时,会把父进程的ID,和子进程(被创建的进程)ID传给回调函数,我们可以同时获取子进程ID和父进程ID。
我们可以通过PsLookupProcessByProcessId函数根据PID获取EPROCESS结构体,进而获取有关被创建进程更多的信息。
详情可以参考https://blog.csdn.net/cssxn/article/details/101352152
线程回调和进程回调的过程大同小异,在此不再详述。


注册表回调


注册表是windows操作系统中的一个核心数据库,其中存放着各种参数,直接控制着windows的启动、硬件驱动程序的装载以及一些windows应用程序的运行,从而在整个系统中起着核心作用。
自然注册表也是驱动病毒必争之地,病毒可以通过注册一个注册表回调来对抗杀软对自己注册表项的清除操作等等。

首先看一下函数声明:    
NTSTATUS CmRegisterCallback( _In_ PEX_CALLBACK_FUNCTION Function, _In_opt_ PVOID Context, _Out_ PLARGE_INTEGER Cookie);
参数

Function[in]
指向RegistryCallback例程的指针。

Context[in]
配置管理器将作为CallbackContext参数传递给RegistryCallback例程的驱动程序定义的值。

Cookie [out]
指向LARGE_INTEGER变量的指针,该变量接收标识回调例程的值。当您注销回调例程时,将此值作为Cookie参数传递给CmUnRegisterCallback。

返回值
成功,则返回 STATUS_SUCCESS;否则,返回其它失败错误码 NTSTATUS。

其中我们需要重点关注的是Function即我们回调函数的地址,以及回调函数的句柄cookie。其中cookie是CmUnRegisterCallback 函数(删除注册表回调函数)唯一的的参数。
接着我们看一下回调函数原型。
EX_CALLBACK_FUNCTION RegistryCallback;NTSTATUS RegistryCallback( _In_ PVOID CallbackContext, _In_opt_ PVOID Argument1, _In_opt_ PVOID Argument2)
参数

CallbackContext [in]
当注册该RegistryCallback例程时,驱动程序作为Context参数传递给CmRegisterCallback或
CmRegisterCallbackEx的值。

Argument1 [in]
一个REG_NOTIFY_CLASS类型的值,用于标识正在执行的注册表操作的类型,以及是否在执行注册表操作之前或之后调用RegistryCallback例程。

Argument2 [in]
指向包含特定于注册表操作类型的信息的结构的指针。结构类型取决于Argument1的REG_NOTIFY_CLASS类型值,如下表所示。有关哪些REG_NOTIFY_CLASS类型的值可用于哪些操作系统版本的信息,请参阅REG_NOTIFY_CLASS。

返回值
成功,则返回 STATUS_SUCCESS;否则,返回其它失败错误码 NTSTATUS。

我们需要重点关注的是Argument1和Argument2。Argument1参数是注册表的操作类型, Argument2 参数作用是获取操作类型对应的结构体数据Object。从结构体数据中,我们可以获取注册表路径对象,调用ObQueryNameString函数根据路径对象获取字符串表示的路径。以此判断是否要拒绝操作的注册表路径,若是,则返回 STATUS_ACCESS_DENIED 拒绝操作,即可实现监控注册表的目的。
详情可以查看https://www.cnblogs.com/csnd/p/12062016.html
本节在分析注册表回调的时候就不再从源码层次去进行分析了,大体流程和进程回调相似,大家可以自行阅读WRK源码。咱们的重点是如何去摘除注册表回调。
代码实现


注册表回调在XP系统上以数组的形式存储,从Windows 2003开始变成了链表结构,这个链表的头称为 CallbackListHead, 可在 CmUnRegisterCallback 中找到:


uf CmUnRegisterCallback(win7 x64)



uf CmUnRegisterCallback(win7 x86)



我们以硬编码的形式获取该链表头地址。
UINT_PTR GetCallbackListHeadAddress(){ UINT_PTR CallbackListHead = 0; UINT_PTR CmUnRegisterCallback = 0;
GetNtosExportVariableAddress(L"CmUnRegisterCallback", (PVOID*)&CmUnRegisterCallback); DbgPrint("%p\r\n", CmUnRegisterCallback);
if (CmUnRegisterCallback) { PUINT8StartSearchAddress = (PUINT8)CmUnRegisterCallback; PUINT8EndSearchAddress = StartSearchAddress + 0x500; PUINT8i = NULL, j = NULL; UINT8 v1 = 0, v2 = 0, v3 = 0; INT32 iOffset = 0;
for (i = StartSearchAddress; i < EndSearchAddress; i++) {#ifdef _WIN64 if (MmIsAddressValid(i) && MmIsAddressValid(i + 1) && MmIsAddressValid(i + 2)) { v1 = *i; v2 = *(i + 1); v3 = *(i + 2); if (v1 == 0x48 && v2 == 0x8d && v3 == 0x0d)// 硬编码 lea rcx { j = i - 5; if (MmIsAddressValid(j) && MmIsAddressValid(j + 1) && MmIsAddressValid(j + 2)) { v1 = *j; v2 = *(j + 1); v3 = *(j + 2); if (v1 == 0x48 && v2 == 0x8d && v3 == 0x54)// 硬编码 lea rdx { RtlCopyMemory(&iOffset, i + 3, 4); CallbackListHead = (UINT_PTR)(iOffset + (UINT64)i + 7); break; } } } }#else if (MmIsAddressValid(i) && MmIsAddressValid(i + 5) && MmIsAddressValid(i + 6)) { v1 = *i; v2 = *(i + 5); v3 = *(i + 6); if (v1 == 0xbf && v2 == 0x8b && v3 == 0xc7)// mov edi ... { RtlCopyMemory(&CallbackListHead, i + 1, 4); break; } }#endif // _WIN64 } }
return CallbackListHead;}

这样我们就可以获取到CallbackListHead地址。接下来就是枚举链表了,注册表回调是一个“结构体链表”, 类似于 EPROCESS,它的定义如下:

typedef struct _CM_NOTIFY_ENTRY{ LIST_ENTRY ListEntryHead; ULONG UnKnown1; ULONG UnKnown2; LARGE_INTEGER Cookie; ULONG64 Context; ULONG64 Function;}CM_NOTIFY_ENTRY, *PCM_NOTIFY_ENTRY;


回调摘除


我们关心的是Cookie和Function。我们想要摘除注册表回调有两种思路,第一种是找到驱动注册的回调的cookie,然后调用CmUnRegisterCallback。
另一种是找到function地址,直接在目标回调地址上写一个 RET, 使其不执行任何代码就返回。
当然还有一种更加暴力的方法,e CmpCallBackCount 0清空计数变量。我们在这里演示第一种方法。
大家如果对第二种方法感兴趣,可以参考http://www.dbgpro.com/archives/4948.html 。
ULONG DelCmpCallback(ULONG64* pPspLINotifyRoutine){ PVOID* h; LARGE_INTEGERcookie; NTSTATUS Status; ULONGsum = 0; ULONG64dwNotifyItemAddr; ULONG64* pNotifyFun; ULONG64* baseNotifyAddr; ULONG64dwNotifyFun; LARGE_INTEGERcmpCookie; PLIST_ENTRYnotifyList; PCM_NOTIFY_ENTRYnotify; dwNotifyItemAddr = *pPspLINotifyRoutine; notifyList = (LIST_ENTRY*)dwNotifyItemAddr; while (!IsListEmpty(notifyList)) { notify = (CM_NOTIFY_ENTRY*)notifyList; if (MmIsAddressValid(notify)) { if (MmIsAddressValid((PVOID)(notify->Function)) && notify->Function > 0x8000000000000000) {
cookie = notify->Cookie;
sum++; } } notifyList = notifyList->Flink; Status = CmUnRegisterCallback(cookie); if (NT_SUCCESS(Status)) {
DbgPrint("删除[CmCallback]Function=%p\t回调", (PVOID)(notify->Function));
}
} return sum;}

运行效果:




关机回调


关机回调顾名思义关机的时候才能触发,真正被调用的函数是驱动的IRP_MJ_SHUTDOWN
派遣例程。

对于驱动病毒来说,它们乐意在此阶段进行清理痕迹以及善后工作,进行攻击的收尾。
NTSTATUS IoRegisterShutdownNotification( IN PDEVICE_OBJECT DeviceObject )

注册关机回调函数的参数只有一个需要绑定的设备对象。


源代码分析



首先调用ExAllocatePoolWithTag函数申请了一块内存,用于存放shutdown结构体。
然后调用IopInterlockedInsertHeadList函数将结构体插入到NotifyShutdown队列之中。
我们查看SHUTDOWN_PACKET为何方神圣。
typedef struct _SHUTDOWN_PACKET { LIST_ENTRY ListEntry; PDEVICE_OBJECT DeviceObject;} SHUTDOWN_PACKET, *PSHUTDOWN_PACKET;


回调摘除
一个ListEntry双向链表,还有一个DeviceObject,设备对象。
Amazing!!!
重大发现,这就说明我们可以通过硬编码来获取IopNotifyShutdownQueueHead地址,进而获取设备对象,通过设备对象又可以回溯找到驱动对象,有了驱动对象岂不是可以为所欲为之为所欲为,嘿嘿嘿。
关机回调摘除的思路也很简单,只需将指定回调从链表中移除即可。

下面来看具体的代码实现:


//获取IopNotifyShutdownQueueHeadNTSTATUS GetIopNotifyShutdownQueueHead(){ULONG_PTR i = 0;ULONG OffsetAddr = 0;LONG OffsetAddr64 = 0;UNICODE_STRING strFunName;RtlInitUnicodeString(&strFunName, L"IoRegisterShutdownNotification");pIoRegisterShutdownNotification = MmGetSystemRoutineAddress(&strFunName);if (pIoRegisterShutdownNotification == NULL)return STATUS_UNSUCCESSFUL;DbgPrint("开始寻找链表head");
//8056ab93 8bd7 mov edx, edi//8056ab95 b9e0285580 mov ecx, offset nt!IopNotifyShutdownQueueHead(805528e0)
for (i = pIoRegisterShutdownNotification; i < pIoRegisterShutdownNotification + 0xff; i++){if (*(PUCHAR)i == 0x8b && *(PUCHAR)(i + 1) == 0xd7 && *(PUCHAR)(i + 2) == 0xb9){RtlCopyMemory(&OffsetAddr, (PUCHAR)(i + 3), sizeof(ULONG_PTR));break;}}
if (OffsetAddr && MmIsAddressValid(OffsetAddr)){NotifyRoutine = (PLIST_ENTRY)OffsetAddr;//DbgPrint("函数获取成功");return STATUS_SUCCESS;}
}

//枚举移除IoRegisterShutdownNotification

NTSTATUS EnumRemoveShutdownNotification(void){//定义变量PLIST_ENTRY entry = NULL;PSHUTDOWN_PACKET shutdown = NULL;PDEVICE_OBJECT DevObj = NULL;PDRIVER_OBJECT DrvObj = NULL;ULONG Dispatch = 0;ULONGsum = 0;

//获取IopNotifyShutdownQueueHeadif (IopNotifyShutdownQueueHead == NULL){IopNotifyShutdownQueueHead = NotifyRoutine;//DbgPrint("获取回调地址成功,%p\t", IopNotifyShutdownQueueHead);if (IopNotifyShutdownQueueHead == NULL)return NULL;}for (entry = IopNotifyShutdownQueueHead->Flink; entry != IopNotifyShutdownQueueHead; entry = entry->Flink){DevObj = (PDEVICE_OBJECT)(*(ULONG*)((ULONG)entry + sizeof(LIST_ENTRY)));//DbgPrint("%p\t", DevObj);DrvObj = DevObj->DriverObject;Dispatch = (ULONG)(DrvObj->MajorFunction[IRP_MJ_SHUTDOWN]);if (NULL != wcsstr(DrvObj->DriverName.Buffer, str.Buffer)) {RemoveEntryList(entry);//(ULONG)(DrvObj->MajorFunction[IRP_MJ_SHUTDOWN]) = NULL;DbgPrint("删除%wZ\t关机回调成功", &DrvObj->DriverName);}else {DbgPrint("[shutdown]0x%X\t\t%wZ\t\t", Dispatch, &DrvObj->DriverName);sum++;}}DbgPrint("目前主机共有关机回调函数:%d\t", sum);return STATUS_SUCCESS;}


当我们成功获取驱动对象后,可以根据驱动名DriverName或者驱动DriverStart来删除指定驱动的关机回调。
在读取从应用层传入的字符串时,忽略了从应用程序传入的字符串为ANSI,就直接赋值给了UNICODE_STRING,结果蓝了一下午,大意了。

运行效果:



结语


对于像我一样刚刚入坑驱动的小伙伴,强烈推荐阅读WRK源码,不仅要知其然,还要知其所以然。虽然读源码的过程很枯燥,但是收获还是挺大的。
参考链接


1.https://blog.csdn.net/cosmoslife/article/details/50395960
2.https://www.write-bug.com/article/2167.html
3.http://www.dbgpro.com/archives/4948.html
4.http://www.doc88.com/p-1187544953268.html
5.http://www.voidcn.com/article/p-qlfudnjc-ty.html


你要的分享、在看与点赞都在这儿~

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存